spb/spboucher.ai Public
spboucher.ai — personal website of Simon-Pierre Boucher.
TypeScript 93.4%
HTML 5.5%
CSS 1%
1/*2 page.tsx3 spboucher.ai Web4 Author: Simon-Pierre Boucher5 Mail: contact@spboucher.ai6*/78import type { Metadata } from "next";9import Image from "next/image";10import Link from "next/link";11import { notFound } from "next/navigation";12import {13 ArrowLeft,14 Award,15 BookOpen,16 Download,17 ExternalLink,18 Github,19 Layers,20 Sparkles,21 Tag,22 Workflow,23} from "lucide-react";2425import { Badge } from "@/components/ui/badge";26import { buttonVariants } from "@/components/ui/button";27import { Card, CardHeader } from "@/components/ui/card";28import { IconTile } from "@/components/icon-tile";29import { Reveal } from "@/components/reveal";30import { SectionHeading } from "@/components/section-heading";31import { getAllProjectLinks, type ProjectLinks } from "@/lib/apps";32import { getDetailIcon } from "@/lib/detail-icons";33import { projectDetails } from "@/lib/project-details";3435interface PageProps {36 params: Promise<{ slug: string }>;37}3839function getProject(slug: string): ProjectLinks | undefined {40 return getAllProjectLinks().find((p) => p.slug === slug);41}4243export function generateStaticParams() {44 return getAllProjectLinks()45 .filter((p) => projectDetails.some((d) => d.slug === p.slug))46 .map((p) => ({ slug: p.slug }));47}4849export async function generateMetadata({50 params,51}: PageProps): Promise<Metadata> {52 const { slug } = await params;53 const project = getProject(slug);54 const detail = projectDetails.find((d) => d.slug === slug);55 if (!project || !detail) return {};56 return {57 title: `${project.name} — ${project.tagline}`,58 description: detail.hero.subheadline,59 };60}6162export default async function ProjectDetailPage({ params }: PageProps) {63 const { slug } = await params;64 const project = getProject(slug);65 const detail = projectDetails.find((d) => d.slug === slug);66 if (!project || !detail) notFound();6768 const statCols =69 detail.stats.length <= 370 ? "sm:grid-cols-3"71 : detail.stats.length === 472 ? "sm:grid-cols-2 lg:grid-cols-4"73 : detail.stats.length === 574 ? "sm:grid-cols-3 lg:grid-cols-5"75 : "sm:grid-cols-3 lg:grid-cols-6";7677 return (78 <div className="mx-auto max-w-5xl px-4 py-16 sm:px-6">79 {/* Back link */}80 <Reveal>81 <Link82 href="/apps"83 className="inline-flex items-center gap-1.5 text-sm font-medium text-muted-foreground transition-colors hover:text-primary"84 >85 <ArrowLeft className="h-4 w-4" aria-hidden="true" />86 All apps & projects87 </Link>88 </Reveal>8990 {/* Hero */}91 <Reveal className="mt-8">92 <div className="flex flex-col gap-6 sm:flex-row sm:items-start">93 {project.icon ? (94 <Image95 src={project.icon}96 alt={`${project.name} icon`}97 width={96}98 height={96}99 priority100 className="h-24 w-24 shrink-0 rounded-[0.9rem] border border-border shadow-[var(--shadow-soft)]"101 />102 ) : (103 <div104 aria-hidden="true"105 className="flex h-24 w-24 shrink-0 items-center justify-center rounded-[0.9rem] border border-foreground/15 bg-card text-5xl shadow-[var(--shadow-soft)]"106 >107 {project.emoji}108 </div>109 )}110 <div className="min-w-0">111 <div className="flex flex-wrap items-center gap-2">112 <Badge>113 {project.kind === "zyquo"114 ? "Zyquo macOS Suite"115 : project.demo116 ? "Web Platform"117 : "Open Source"}118 </Badge>119 {project.language && (120 <Badge variant="outline">{project.language}</Badge>121 )}122 </div>123 <h1 className="mt-3 font-display text-3xl font-bold tracking-tight sm:text-4xl">124 {project.name}125 </h1>126 <p className="mt-2 text-lg font-medium text-foreground/85">127 {detail.hero.headline}128 </p>129 <p className="mt-2 max-w-2xl text-sm leading-relaxed text-muted-foreground">130 {detail.hero.subheadline}131 </p>132 <div className="mt-5 flex flex-wrap gap-2">133 <a134 href={project.repo}135 target="_blank"136 rel="noopener noreferrer"137 className={buttonVariants({ variant: "outline", size: "sm" })}138 >139 <Github aria-hidden="true" />140 GitHub Repo141 </a>142 {project.release && (143 <a144 href={project.release}145 target="_blank"146 rel="noopener noreferrer"147 className={buttonVariants({ variant: "outline", size: "sm" })}148 >149 <Tag aria-hidden="true" />150 Release151 </a>152 )}153 {project.demo && (154 <a155 href={project.demo}156 target="_blank"157 rel="noopener noreferrer"158 className={buttonVariants({ variant: "default", size: "sm" })}159 >160 <ExternalLink aria-hidden="true" />161 Visit {project.demo.replace(/^https?:\/\//, "")}162 </a>163 )}164 {project.dmg && (165 <a166 href={project.dmg}167 className={buttonVariants({ variant: "default", size: "sm" })}168 >169 <Download aria-hidden="true" />170 Download DMG171 </a>172 )}173 </div>174 </div>175 </div>176 </Reveal>177178 {/* Stats band */}179 {detail.stats.length > 0 && (180 <Reveal className="mt-14">181 <div className={`grid grid-cols-2 gap-x-8 gap-y-8 ${statCols}`}>182 {detail.stats.map((stat) => (183 <div key={stat.label} className="border-t-2 border-foreground pt-3">184 <p className="font-display text-2xl font-semibold tracking-tight text-foreground sm:text-3xl">185 {stat.value}186 </p>187 <p className="mt-1.5 font-mono text-[0.66rem] uppercase leading-relaxed tracking-[0.12em] text-muted-foreground">188 {stat.label}189 </p>190 </div>191 ))}192 </div>193 </Reveal>194 )}195196 {/* Overview */}197 <section className="mt-14">198 <Reveal>199 <SectionHeading title="Overview" icon={BookOpen} />200 </Reveal>201 <Reveal delay={0.06}>202 <div className="mt-4 max-w-3xl space-y-4">203 {detail.overview.map((paragraph) => (204 <p205 key={paragraph.slice(0, 48)}206 className="text-sm leading-relaxed text-muted-foreground sm:text-[0.95rem]"207 >208 {paragraph}209 </p>210 ))}211 </div>212 </Reveal>213 </section>214215 {/* Features */}216 {detail.features.length > 0 && (217 <section className="mt-14">218 <Reveal>219 <SectionHeading title="Key Features" icon={Sparkles} />220 </Reveal>221 <div className="mt-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">222 {detail.features.map((feature, i) => (223 <Reveal key={feature.title} delay={i * 0.05} className="h-full">224 <Card className="h-full hover:-translate-y-1 hover:border-primary/25 hover:shadow-[var(--shadow-lift)]">225 <CardHeader>226 <IconTile icon={getDetailIcon(feature.icon)} size="sm" />227 <h3 className="pt-2 text-sm font-semibold tracking-tight">228 {feature.title}229 </h3>230 <p className="text-xs leading-relaxed text-muted-foreground">231 {feature.description}232 </p>233 </CardHeader>234 </Card>235 </Reveal>236 ))}237 </div>238 </section>239 )}240241 {/* How it works */}242 {detail.architecture.length > 0 && (243 <section className="mt-14">244 <Reveal>245 <SectionHeading title="How It Works" icon={Workflow} />246 </Reveal>247 <ol className="mt-6 space-y-4">248 {detail.architecture.map((step, i) => (249 <Reveal key={step.title} delay={i * 0.06}>250 <li className="flex items-start gap-4">251 <span252 aria-hidden="true"253 className="mt-1 pt-0.5 font-mono text-sm font-semibold text-primary"254 >255 {String(i + 1).padStart(2, "0")}256 </span>257 <div className="min-w-0 pt-1">258 <h3 className="text-sm font-semibold tracking-tight">259 {step.title}260 </h3>261 <p className="mt-1 max-w-2xl text-sm leading-relaxed text-muted-foreground">262 {step.description}263 </p>264 </div>265 </li>266 </Reveal>267 ))}268 </ol>269 </section>270 )}271272 {/* Tech stack */}273 {detail.techStack.length > 0 && (274 <section className="mt-14">275 <Reveal>276 <SectionHeading title="Tech Stack" icon={Layers} />277 </Reveal>278 <div className="mt-6 space-y-5">279 {detail.techStack.map((group, i) => (280 <Reveal key={group.category} delay={i * 0.05}>281 <div>282 <p className="text-xs font-semibold uppercase tracking-[0.16em] text-primary">283 {group.category}284 </p>285 <div className="mt-2 flex flex-wrap gap-2">286 {group.items.map((item) => (287 <Badge key={item} variant="secondary">288 {item}289 </Badge>290 ))}291 </div>292 </div>293 </Reveal>294 ))}295 </div>296 </section>297 )}298299 {/* Highlights */}300 {detail.highlights.length > 0 && (301 <section className="mt-14">302 <Reveal>303 <SectionHeading title="Highlights" icon={Award} />304 </Reveal>305 <ul className="mt-6 grid gap-3 sm:grid-cols-2">306 {detail.highlights.map((highlight, i) => (307 <Reveal key={highlight} delay={i * 0.05} className="h-full">308 <li className="flex h-full items-start gap-3 border border-border bg-card px-5 py-4 shadow-[var(--shadow-soft)]">309 <Sparkles310 className="mt-0.5 h-4 w-4 shrink-0 text-primary"311 aria-hidden="true"312 />313 <span className="text-sm leading-relaxed text-foreground/85">314 {highlight}315 </span>316 </li>317 </Reveal>318 ))}319 </ul>320 </section>321 )}322323 {/* CTA */}324 <Reveal className="mt-16">325 <div className="border-y-2 border-foreground bg-card/50 p-8 text-center sm:p-10">326 <h2 className="font-display text-xl font-semibold tracking-tight">327 Explore {project.name}328 </h2>329 <p className="mx-auto mt-2 max-w-xl text-sm text-muted-foreground">330 {project.tagline} — the full source is on GitHub.331 </p>332 <div className="mt-5 flex flex-wrap justify-center gap-2">333 <a334 href={project.repo}335 target="_blank"336 rel="noopener noreferrer"337 className={buttonVariants({ variant: "default", size: "sm" })}338 >339 <Github aria-hidden="true" />340 View source341 </a>342 {project.demo && (343 <a344 href={project.demo}345 target="_blank"346 rel="noopener noreferrer"347 className={buttonVariants({ variant: "outline", size: "sm" })}348 >349 <ExternalLink aria-hidden="true" />350 Open the live site351 </a>352 )}353 {project.dmg && (354 <a355 href={project.dmg}356 className={buttonVariants({ variant: "outline", size: "sm" })}357 >358 <Download aria-hidden="true" />359 Download DMG360 </a>361 )}362 </div>363 </div>364 </Reveal>365 </div>366 );367}368